feat(oid4vc): add mDOC credential issuance and verification (ISO 18013-5)#22
Merged
burdettadam merged 39 commits intomainfrom Mar 11, 2026
Merged
feat(oid4vc): add mDOC credential issuance and verification (ISO 18013-5)#22burdettadam merged 39 commits intomainfrom
burdettadam merged 39 commits intomainfrom
Conversation
46ff341 to
5f0251d
Compare
5f0251d to
f87bc94
Compare
e23e7c9 to
00bbb1c
Compare
00bbb1c to
253f9cb
Compare
253f9cb to
91eed74
Compare
91eed74 to
5bb3912
Compare
…tion Implements OID4VCI mso_mdoc credential issuance and OID4VP mDOC presentation verification using the isomdl-uniffi Rust library. Key changes: - Rewrite mso_mdoc credential processor with isomdl-uniffi bindings - Add mDOC issuer (mdoc/issuer.py) and verifier (mdoc/verifier.py) - Add MSO issuer/verifier (consolidated from mso/ into mdoc/) - Add key generation routes for mDOC signing keys - Add storage layer: trust anchors, certificates, keys, config - Add x.509 cert chain handling and PEM splitting utilities - Add trust anchor guard (fail-closed) and cert expiry validation - Remove superseded mso/ package and x509.py (merged into mdoc/) - Update Docker/CI to install isomdl-uniffi platform wheel - Add OID4VC conformance tests GitHub Actions workflow - Fix ConnectError retry in integration test credo_client fixture Signed-off-by: Adam Burdett <burdettadam@gmail.com>
The upstream isomdl-uniffi library now exposes issuer_signed_b64() which serialises directly to an IssuerSigned struct that carries the correct serde rename attributes for ISO 18013-5 section 8.3 camelCase keys (issuerAuth, nameSpaces) and array namespace values. This removes the Python-side _patch_mdoc_keys workaround which had to decode CBOR, rename keys by hand, and re-encode. The fix is now in the right layer (Rust serialisation types) rather than a post-processing hack. Change summary: - Remove import base64 (only used by _patch_mdoc_keys) - Remove _patch_mdoc_keys() entirely - Replace stringify() + _patch_mdoc_keys() call with mdoc.issuer_signed_b64() - Add test_mdoc_sign_emits_iso_cbor_keys to verify camelCase keys and array namespace values end-to-end through isomdl_mdoc_sign() Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Namespace element values are now passed to Mdoc.create_and_sign() as JSON strings (stdlib json.dumps) rather than CBOR bytes (cbor2.dumps). The Rust layer gains a json_to_cbor() converter so it internalises the CBOR encoding, eliminating the need for callers to own a CBOR library. Changes: - mso_mdoc/mdoc/issuer.py: remove `import cbor2`; cbor2.dumps -> json.dumps in _prepare_mdl_namespaces and _prepare_generic_namespaces - integration/tests/mdoc/test_pki.py: namespace inputs updated to json.dumps; cbor2 retained (hard import) for the DeviceResponse construction below - pyproject.toml: cbor2 removed from [dependencies] optional and from mso_mdoc extras; added to [tool.poetry.group.dev.dependencies] Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Trust anchors are exclusively stored in and retrieved from the Askar wallet. Sub-wallets maintain their own trust registry with their own root authority certificates. - Remove FileTrustStore (filesystem PEM directory) entirely - Remove OID4VC_MDOC_TRUST_STORE_TYPE env var and create_trust_store() - verify_credential / verify_presentation always build a fresh WalletTrustStore(profile) from the calling profile per-request, ensuring each tenant Askar partition is queried correctly - Simplify plugin __init__.py / on_startup (no trust store init at startup) - Remove TestFileTrustStore unit tests (class no longer exists) - Rewrite test_wallet_trust_store_per_request.py for always-wallet design - Remove FileTrustStore imports from test_review_issues / test_verifier Signed-off-by: Adam Burdett <burdettadam@gmail.com>
7b7e3b1 to
d078461
Compare
… API - issuer.py: use create_and_sign_mdl for mDL path (accepts JSON strings, handles CBOR encoding internally); call issuer_signed_b64() for ISO 18013-5 compliant IssuerSigned CBOR output - nonce.py: stringify bool tags for Askar compatibility (fixes token endpoint) - demo/setup.sh: source .env before URL defaults so port overrides are honoured - demo/demo.spec.ts: add required portrait and un_distinguishing_sign fields to mDL credential subject (required by OrgIso1801351::from_json) Signed-off-by: Adam Burdett <burdettadam@gmail.com>
f047235 to
085dca5
Compare
Avoids conflict with port 8021 on the host. The .env already used 8121/8122 but the docker-compose defaults fell back to 8021/8022 when .env was absent. Signed-off-by: Adam Burdett <burdettadam@gmail.com>
NUXT_PUBLIC_ISSUER_CALLBACK_URL and waltid-proxy host port defaults were 7101 but .env sets WALLET_PORT=7201; align docker-compose defaults to avoid mismatch when running without .env overrides. Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Issuer host ports: 8021/8022 → 8121/8122 Wallet host port: 7101 → 7201 Keeps documented defaults consistent with the compose file so that a bare 'cp .env.example .env' works without manual edits. Signed-off-by: Adam Burdett <burdettadam@gmail.com>
- zrok reserve example: localhost:8022 → localhost:8122 - comment: default ports (8021/8022) → (8121/8122) Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Issuer admin: 8021 → 8121 Issuer OID4VCI: 8022 → 8122 Wallet proxy: 7101 → 7201 Updated in: quick start, services table, zrok example, architecture diagram, and curl examples. Signed-off-by: Adam Burdett <burdettadam@gmail.com>
- Opening statement now references OID4VCI 1.0 final - Footer link updated from -1_0-11.html to -1_0.html - Drop 'experimental' / 'in active development' language now that the final spec is published Signed-off-by: Adam Burdett <burdettadam@gmail.com>
OID4VCI 1.0 Appendix B.2 / E.2.1 requires claims to be a non-empty
array of claim descriptor objects, not a namespace-keyed map.
transform_issuer_metadata() now converts the stored format:
{namespace: {claim_name: {mandatory, display}}}
to the spec-compliant array form:
[{path: [namespace, claim_name], mandatory: ..., display: ...}]
Add unit tests covering the claims transform, COSE alg conversion,
and the no-op case when claims is already an array.
Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Per OID4VCI 1.0 Appendix A.2.2 and Appendix B.2, the claims field in
mso_mdoc credential issuer metadata must be a flat array of claim
description objects with path: [namespace, claim_name], not a
namespace-keyed dict.
The stored format_data.claims remains {namespace: {claim_name: descriptor}}
for backwards compatibility. transform_issuer_metadata() now converts this
to the spec-mandated array form on the way out:
Before: {'org.iso.18013.5.1': {'given_name': {'mandatory': true}}}
After: [{'path': ['org.iso.18013.5.1', 'given_name'], 'mandatory': true}]
Update unit tests to assert the correct array output.
Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Per OID4VCI 1.0 Section 12.2.4 and Appendix A.2.2, the claims array
and display array for mso_mdoc credentials must be nested inside
credential_metadata, not emitted at the top level of the credential
configuration object.
transform_issuer_metadata now:
- Pops 'claims' (namespace-dict) → converts to path-array
[{path: [namespace, claim_name], mandatory, display}]
- Pops 'display' from the metadata top level
- Places both inside credential_metadata per spec
Output structure:
{
'format': 'mso_mdoc',
'doctype': '...',
'credential_signing_alg_values_supported': [-7],
'credential_metadata': {
'display': [...],
'claims': [{path: [ns, name], mandatory: true}, ...]
}
}
Update unit tests accordingly.
Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Wallets such as walt.id include an explicit :443 in the proof JWT aud (https://issuer.example.com:443) even though HTTPS 443 is the default port. Per RFC 3986 these URLs are semantically identical to the same URL without the port, but string comparison fails. Strip the default port from both the aud values and the configured issuer endpoint before comparing so https://host:443 == https://host. Signed-off-by: Adam Burdett <burdettadam@gmail.com>
…y material Some draft-era wallets (e.g. walt.id) send proof JWTs where the header contains only alg and typ — no jwk, kid, or x5c. Instead they put their DID in the payload iss claim (e.g. did:key:...) and expect the server to resolve the verification key from it. When no key material is found in the proof header, fall back to decoding the payload and attempting key_material_for_kid() on the iss claim. Also derive holder_jwk from the resolved key so mso_mdoc DeviceKey binding works correctly. Also tighten holder_jwk derivation to cover the iss-resolved path (not just kid-resolved path). Signed-off-by: Adam Burdett <burdettadam@gmail.com>
…ling - Remove module-level _mso_mdoc_processor variable and its Optional import; the processor is only used locally within setup() so no global needed - Remove try/except in on_startup that was silently swallowing errors; plugin startup failures should propagate and fail loudly - Fix key_material_for_kid call in token.py to pass a DID URL (with fragment) rather than a bare DID, using #0 as fallback fragment Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Auto-generating a self-signed test key at startup is inappropriate for production deployments and masks misconfiguration. Replace with a clear warning directing operators to use the admin API to provision keys. Signed-off-by: Adam Burdett <burdettadam@gmail.com>
- Add SUPPORTED_ALGS module-level constant (EdDSA, ES256) as single source of truth for accepted algorithms - Validate presence of required 'alg' header (RFC 7515 §4.1.1) with clear error message and spec citation - Reject unsupported alg values listing accepted options - Enforce mutual exclusivity of kid/jwk/x5c key-identification params (RFC 7515 §4.1) with explicit conflict reporting - Raise descriptive errors when none of kid/jwk/x5c are present - Extend jwt_verify key resolution to support all three methods (jwk, kid, x5c) consistent with handle_proof_of_posession - Improve alg/key-type mismatch error messages to describe the conflict Signed-off-by: Adam Burdett <burdettadam@gmail.com>
resolve_signing_key_for_credential was storing the generated key but
never calling store_config, so get_default_signing_key had to fall back
to list_keys()[0] — which is unreliable when multiple keys exist.
- Add store_config("default_signing_key", {"key_id": "default"}) inside
the same try block as store_signing_key so the config is only written
when the key actually persisted.
- Add a comment in _resolve_signing_key explaining why the return value
of resolve_signing_key_for_credential is intentionally discarded
(it returns a raw JWK; this method needs the full key_data struct).
- Add regression tests in TestResolveSigningKeyPersistsDefaultConfig and
TestResolveSigningKeyUsesGeneratedKey covering both bugs.
Signed-off-by: Adam Burdett <burdettadam@gmail.com>
…ules key_routes.py mixed signing key/certificate management with trust anchor management — two unrelated concerns in one 460-line file. - key_routes.py: now covers only signing key and certificate schemas, handlers, and route registration (register_key_routes). - trust_anchor_routes.py (new): trust anchor schemas, handlers, and route registration (register_trust_anchor_routes). - routes.py: imports and calls both register functions separately. - register_key_management_routes alias kept in key_routes.py for backward compatibility. Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Store a self-signed certificate whenever a new signing key is generated in resolve_signing_key_for_credential (both the default-key path and the verification-method path), so every key always has a certificate on record. Replace the on-demand fallback in issue() and the mdoc_sign route handler with a hard CredProcessorError / ValueError: missing certificates are now a programmer error, not a silent auto-repair. Remove unused imports from routes.py (uuid, datetime, timedelta, generate_self_signed_certificate) that were only needed by the removed fallback block. Add tests: - TestResolveSigningKeyStoresCertOnGeneration: verifies a cert is stored for both generation paths and is NOT stored for pre-existing keys. - TestMissingCertRaisesCredProcessorError: verifies the hard error fires when get_certificate_for_key returns None. - Update TestResolveSigningKeyPersistsDefaultConfig to mock generate_self_signed_certificate alongside generate_ec_key_pair so the fake PEM string does not reach the real certificate builder. Signed-off-by: Adam Burdett <adam@indicio.tech>
test_review_issues.py was named after an internal code-review artifact. Rename to test_cred_processor_and_verifier_unit.py, which accurately describes the four areas covered: MsoMdocCredProcessor, MsoMdocCredVerifier / MsoMdocPresVerifier / WalletTrustStore, key-generation and certificate utilities, and mso_mdoc storage operations. Update the module docstring accordingly. Signed-off-by: Adam Burdett <adam@indicio.tech>
…ting key When resolve_signing_key_for_credential is called with a verification_method that is not in storage, silently generating a new random key and binding it to that VM ID is incorrect: the generated key has no relationship to the DID document's actual key material for that method. Raise CredProcessorError with a clear message directing the operator to register the key via the key management API before issuing. Update tests: replace test_verification_method_key_generation_stores_certificate (which tested the wrong behaviour) with: - test_unknown_verification_method_raises: asserts CredProcessorError is raised and storage is not touched. - test_known_verification_method_returned_without_cert_write: asserts an existing VM key is returned immediately without writing a certificate. Signed-off-by: Adam Burdett <adam@indicio.tech>
No signing key configured is now a hard CredProcessorError in both resolve_signing_key_for_credential and _resolve_signing_key. Operators must register keys explicitly via the key management API. Remove now-unused imports: uuid, timedelta, StorageError, generate_ec_key_pair, generate_self_signed_certificate. Update tests: replace generation/storage side-effect assertions with tests that assert CredProcessorError is raised when no key is configured. Add direct test for _resolve_signing_key raising when storage is empty. Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Bug 1 - store_config overwrites operator default: Only call store_config when "default_signing_key" config is absent. Previously any run that loaded the env-var key for the first time would silently replace whatever key the operator had registered. Bug 2 - inconsistent storage API: Replace get_key (returns raw JWK only) with get_signing_key (returns the full record, consistent with every other call-site in _resolve_signing_key). Bug 3 - silent failure masked by misleading error: Replace bare except/log with re-raise as CredProcessorError with a message that names the failing file. Previously a bad PEM raised "No default signing key is configured" with no indication of why. Add TestStaticEnvVarKeyLoading with four tests that each expose one of the above bugs (plus the happy-path complement for Bug 1). Signed-off-by: Adam Burdett <adam@indicio.tech>
…oad modules Extract standalone functions from cred_processor.py into focused modules: - signing_key.py: check_certificate_not_expired + resolve_signing_key_for_credential - payload.py: prepare_mdoc_payload + normalize_mdoc_result cred_processor.py retains MsoMdocCredProcessor and re-exports the extracted public names. Private methods _prepare_payload and _normalize_mdoc_result remain as one-liner delegates to preserve the existing test API. Update 6 test patch paths from mso_mdoc.cred_processor.MdocStorageManager to mso_mdoc.signing_key.MdocStorageManager for tests of the standalone resolve_signing_key_for_credential function; update debug-log capture logger from mso_mdoc.cred_processor to mso_mdoc.payload. Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Add cryptography >=42 as a direct dependency of the oid4vc plugin. This allows the `from cryptography import x509` import in signing_key.py to sit at the top of the module rather than inside the function body, avoiding the PLC0415 lint exemption. Signed-off-by: Adam Burdett <burdettadam@gmail.com>
…, and pres_verifier modules verifier.py was ~839 lines. Split into three focused modules: - trust_store.py: TrustStore protocol + WalletTrustStore - cred_verifier.py: MsoMdocCredVerifier + parsing helpers (PreverifiedMdocClaims, _parse_string_credential, _extract_mdoc_claims) - pres_verifier.py: MsoMdocPresVerifier + OID4VP helpers + mdoc_verify (extract_mdoc_item_value, extract_verified_claims, MdocVerifyResult) verifier.py is now a thin re-exporter for backward compatibility. Update all test patch targets to reference the new module paths: - mso_mdoc.mdoc.verifier.isomdl_uniffi -> cred_verifier / pres_verifier - mso_mdoc.mdoc.verifier.Config -> pres_verifier - mso_mdoc.mdoc.verifier.retrieve_or_create_did_jwk -> pres_verifier - mso_mdoc.mdoc.verifier.MdocStorageManager -> trust_store Tests that exercise mdoc_verify() also patch cred_verifier.isomdl_uniffi since _parse_string_credential lives there and holds its own module reference. Signed-off-by: Adam Burdett <adam@indicio.tech>
3f30441 to
39c51cc
Compare
Split verifier.py into focused modules (trust_store.py, cred_verifier.py, pres_verifier.py). Further split pres_verifier.py into mdoc_item.py and mdoc_verify.py for better separation of concerns. Also strip C-N and M-N audit report item labels from comments and docstrings throughout the mso_mdoc plugin, as the referenced report has been removed. Changes: - Split verifier.py → trust_store.py, cred_verifier.py, pres_verifier.py - Split pres_verifier.py → mdoc_item.py, mdoc_verify.py - Update all imports to use focused modules - Remove audit report labels (C-N, M-N prefixes) from comments Signed-off-by: Adam Burdett <burdettadam@gmail.com>
39c51cc to
5c1b2bd
Compare
I, Adam Burdett <burdettadam@gmail.com>, hereby add my Signed-off-by to this commit: 3f616aa I, Adam Burdett <burdettadam@gmail.com>, hereby add my Signed-off-by to this commit: 756d853 I, Adam Burdett <burdettadam@gmail.com>, hereby add my Signed-off-by to this commit: d2286cd I, Adam Burdett <burdettadam@gmail.com>, hereby add my Signed-off-by to this commit: 76c48e3 I, Adam Burdett <burdettadam@gmail.com>, hereby add my Signed-off-by to this commit: e65f290 Signed-off-by: Adam Burdett <burdettadam@gmail.com>
- Remove unused json import from payload.py - Remove unused ProfileSession import from cred_processor.py - Remove unnecessary ellipsis from TrustStore protocol Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Signed-off-by: Adam Burdett <burdettadam@gmail.com>
- Remove unused MdocStorageManager import from pres_verifier.py - Move flatten_trust_anchors import to top level in cred_verifier.py - Apply ruff formatting to all oid4vc files Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Import generate_self_signed_certificate into cred_processor module namespace so test_expired_certificate.py can patch it at mso_mdoc.cred_processor.generate_self_signed_certificate. Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Signed-off-by: Adam Burdett <burdettadam@gmail.com>
v0.1.0-test9 was missing create_and_sign_mdl() and issuer_signed_b64() which caused 5 test failures and 4 errors in the mso_mdoc test suite. v0.1.0-test11 includes these methods and all tests now pass. Signed-off-by: Adam Burdett <burdettadam@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
mDOC support via isomdl-uniffi. Depends on #21.